prosemirror 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +14 -0
- data/README.md +49 -4
- data/ROADMAP.md +9 -5
- data/TYPES.md +55 -0
- data/lib/prosemirror/checker.rb +2 -2
- data/lib/prosemirror/coerce.rb +7 -0
- data/lib/prosemirror/document.rb +6 -0
- data/lib/prosemirror/html/element.rb +1 -1
- data/lib/prosemirror/html/selector.rb +12 -4
- data/lib/prosemirror/html_parser.rb +9 -2
- data/lib/prosemirror/json_parser.rb +71 -40
- data/lib/prosemirror/mark.rb +8 -3
- data/lib/prosemirror/mark_type.rb +15 -0
- data/lib/prosemirror/markdown/builder.rb +65 -13
- data/lib/prosemirror/markdown_serializer.rb +518 -0
- data/lib/prosemirror/marks/basic/code.rb +2 -0
- data/lib/prosemirror/marks/basic/em.rb +2 -0
- data/lib/prosemirror/marks/basic/link.rb +2 -0
- data/lib/prosemirror/marks/basic/strong.rb +2 -0
- data/lib/prosemirror/marks/bold.rb +2 -0
- data/lib/prosemirror/marks/code.rb +2 -0
- data/lib/prosemirror/marks/italic.rb +2 -0
- data/lib/prosemirror/marks/link.rb +2 -0
- data/lib/prosemirror/marks/strike.rb +2 -0
- data/lib/prosemirror/node.rb +21 -6
- data/lib/prosemirror/node_type.rb +13 -0
- data/lib/prosemirror/nodes/basic/code_block.rb +4 -0
- data/lib/prosemirror/nodes/basic/image.rb +4 -0
- data/lib/prosemirror/nodes/basic/ordered_list.rb +4 -0
- data/lib/prosemirror/nodes/blockquote.rb +4 -0
- data/lib/prosemirror/nodes/bullet_list.rb +15 -0
- data/lib/prosemirror/nodes/code_block.rb +15 -0
- data/lib/prosemirror/nodes/details.rb +27 -0
- data/lib/prosemirror/nodes/details_content.rb +12 -0
- data/lib/prosemirror/nodes/details_summary.rb +12 -0
- data/lib/prosemirror/nodes/hard_break.rb +5 -0
- data/lib/prosemirror/nodes/heading.rb +6 -0
- data/lib/prosemirror/nodes/horizontal_rule.rb +5 -0
- data/lib/prosemirror/nodes/image.rb +16 -0
- data/lib/prosemirror/nodes/list_item.rb +4 -0
- data/lib/prosemirror/nodes/mention.rb +36 -0
- data/lib/prosemirror/nodes/ordered_list.rb +11 -0
- data/lib/prosemirror/nodes/paragraph.rb +8 -0
- data/lib/prosemirror/nodes/table.rb +36 -0
- data/lib/prosemirror/nodes/task_item.rb +40 -0
- data/lib/prosemirror/nodes/task_list.rb +24 -0
- data/lib/prosemirror/nodes/text.rb +4 -0
- data/lib/prosemirror/nodes/twitch.rb +94 -0
- data/lib/prosemirror/nodes/youtube.rb +71 -0
- data/lib/prosemirror/schema.rb +22 -6
- data/lib/prosemirror/text_align.rb +31 -0
- data/lib/prosemirror/type_definition.rb +33 -13
- data/lib/prosemirror/version.rb +1 -1
- metadata +11 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8aae1987528266c7bd7c4b3c3d8f7c82e74f74cf89ee9e7ac87b1732ff76634d
|
|
4
|
+
data.tar.gz: d5cb877b264eb0d39cd40cd8b4909ec94b423ac439a6719df23c70eb6cde6182
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 947eab5fdc041f059be90be81b94bc66bb1505118b983ed982100faf1ccf2748fdcf753f83384385af72e80cc610473e24eeca31aa6a404ed93f431a8d3138d2
|
|
7
|
+
data.tar.gz: 4a56748f16cc7ac1af9ad1660518870dfded5f22b089e714417cf74afb970e0ec8b9f53d2d9e1adc82662efac63c449ba1dff10d07988ed8ca110413143c2452
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [0.6.0] - 2026-10-24
|
|
4
|
+
|
|
5
|
+
- Tiptap's YouTube and Twitch embeds, in `Schema.tiptap`: `youtube` and `twitch`, with the video's URL in `src`, rendered as an iframe of its embed URL, built from the video's id. They are imported from Tiptap's markup, and from iframes of embed URLs without it. Subclasses set Twitch's `parent` domain and YouTube's nocookie host. Markdown export writes a link to the video, or the iframe with `html: true`.
|
|
6
|
+
- Tiptap's details, in `Schema.tiptap`: `details`, `details_summary` and `details_content`, rendered and imported as Tiptap does, including `<details>` without Tiptap's content wrapper. `open`, which Tiptap keeps with `persist: true`, is rendered and imported. Markdown export writes them as `<details>` HTML around Markdown content, as GitHub reads it.
|
|
7
|
+
- `Schema.tiptap(text_align: true)` adds Tiptap's TextAlign extension to headings and paragraphs: a `textAlign` attribute, rendered as a `text-align` style, and imported from it or from the old `align` attribute. Other types get it by including `Prosemirror::TextAlign`.
|
|
8
|
+
- Tiptap's task lists, in `Schema.tiptap`: `task_list` and `task_item`, with a `checked` attribute. They render Tiptap's HTML, and are imported from it and from GitHub's. Markdown import reads GitHub Flavored Markdown task lists: a bullet list whose items all have a checkbox is a task list, and other checkboxes stay as text. Markdown export writes `- [x]` items.
|
|
9
|
+
- Tiptap's mention node, in `Schema.tiptap`: rendered as `<span data-type="mention">@label</span>`, with its `id`, `label` and `mentionSuggestionChar` in `data-` attributes, and imported from that HTML. Its text is `@label`, or `@id` without a label. In Markdown, it's written as text, or as HTML with `html: true`.
|
|
10
|
+
|
|
11
|
+
## [0.5.0] - 2026-10-24
|
|
12
|
+
|
|
13
|
+
- `Document#to_markdown` writes Markdown, with GitHub Flavored Markdown tables and strikethrough, like prosemirror-markdown. Node types write themselves in `to_markdown`, and mark types declare their syntax with `markdown`. `html: true` writes underline, highlight, subscript, superscript and image sizes as HTML.
|
|
14
|
+
- `Document#check` accepts attributes whose JSON name differs from their property name, like a link's `class`. It reported them as invalid.
|
|
15
|
+
- Faster: parsing JSON takes less than half the time, rendering and `to_h` about a third less, and HTML and Markdown import about a quarter less, with the same results. Strings in documents parsed from a JSON string are frozen, including the values of unknown attributes.
|
|
16
|
+
|
|
3
17
|
## [0.4.0] - 2026-10-24
|
|
4
18
|
|
|
5
19
|
- `Document#transform` builds a new document from a block called with each node, children first, which keeps, replaces, removes or splices nodes. Required content that goes missing is added back and reported.
|
data/README.md
CHANGED
|
@@ -24,6 +24,7 @@ doc = Prosemirror.parse(record.content) # a Hash or a JSON string
|
|
|
24
24
|
|
|
25
25
|
doc.to_html # => "<h2>Hello</h2><p>World</p>"
|
|
26
26
|
doc.to_text # => "Hello\n\nWorld"
|
|
27
|
+
doc.to_markdown # => "## Hello\n\nWorld\n"
|
|
27
28
|
doc.to_h # => { "type" => "doc", "content" => [...] }
|
|
28
29
|
```
|
|
29
30
|
|
|
@@ -108,15 +109,34 @@ bundle add commonmarker
|
|
|
108
109
|
doc = Prosemirror.from_markdown("## Hello\n\nSome **bold** text.")
|
|
109
110
|
```
|
|
110
111
|
|
|
111
|
-
It reads GitHub Flavored Markdown: tables, strikethrough and
|
|
112
|
+
It reads GitHub Flavored Markdown: tables, strikethrough, autolinks and task lists. Each Markdown node becomes the element commonmarker renders for it, and the schema's parse rules apply as in HTML import, so custom types can match Markdown too: a type that matches `img` also gets Markdown images. For the built-in types, it gives the same JSON as rendering the Markdown to HTML and importing it in Tiptap, except that:
|
|
112
113
|
|
|
113
114
|
- code blocks lose their final newline, as in prosemirror-markdown;
|
|
114
|
-
-
|
|
115
|
+
- a bullet list whose items all have a checkbox (`- [ ] todo`) is a task list. In other lists, which Tiptap's task lists can't hold, checkboxes stay as text at the start of their item: `[ ] ` or `[x] `.
|
|
115
116
|
|
|
116
117
|
Raw HTML in Markdown goes through HTML import when the herb gem is installed, and stays as text otherwise.
|
|
117
118
|
|
|
118
119
|
YAML front matter, between `---` lines at the start, is dropped. Footnotes aren't supported yet: `[^1]: Note` reads as a link definition, as in CommonMark. Markdown nested more than 1,000 levels deep raises `Prosemirror::Error`.
|
|
119
120
|
|
|
121
|
+
## Markdown export
|
|
122
|
+
|
|
123
|
+
`to_markdown` writes the document as Markdown, with GitHub Flavored Markdown for tables, strikethrough and task lists, like [prosemirror-markdown](https://github.com/ProseMirror/prosemirror-markdown). It needs no gem.
|
|
124
|
+
|
|
125
|
+
```ruby
|
|
126
|
+
doc.to_markdown # => "## Hello\n\n**World**\n"
|
|
127
|
+
doc.to_markdown(html: true) # underline, highlight, subscript, superscript and mentions as HTML
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
- Text is escaped, so that it reads back as text: `*`, `_`, `[`, a `#` or `-` that would start a block, HTML-like `<b>`, and URLs that GitHub would turn into links.
|
|
131
|
+
- Markdown has no syntax for underline, highlight, subscript, superscript, image sizes or mentions: by default, only their text or image is written, like `@Ada` for a mention. With `html: true`, they are written as HTML.
|
|
132
|
+
- Emphasis that CommonMark can't write where it is, like bold on a comma between two letters, is dropped and its text kept.
|
|
133
|
+
- Tables are written as GFM tables. A cell's paragraphs and line breaks are joined with `<br>`, and its other blocks, like lists, are written as HTML.
|
|
134
|
+
- Details are written as `<details>` HTML, as on GitHub, with their content in Markdown.
|
|
135
|
+
- YouTube and Twitch embeds are written as links to the video. With `html: true`, they are written as their iframe.
|
|
136
|
+
- Empty paragraphs are left out.
|
|
137
|
+
|
|
138
|
+
Importing the Markdown gives the document back, as far as Markdown can hold it. Custom types write their own Markdown: see [Node and mark types](TYPES.md#markdown-export).
|
|
139
|
+
|
|
120
140
|
## Text import
|
|
121
141
|
|
|
122
142
|
```ruby
|
|
@@ -130,8 +150,13 @@ Blank lines separate paragraphs, and other line breaks become hard breaks. Space
|
|
|
130
150
|
Two schemas are included:
|
|
131
151
|
|
|
132
152
|
```ruby
|
|
133
|
-
|
|
134
|
-
|
|
153
|
+
# The default: Tiptap's StarterKit, with tables, images, task lists,
|
|
154
|
+
# details, YouTube and Twitch embeds, mentions, underline, subscript,
|
|
155
|
+
# superscript and highlight
|
|
156
|
+
Prosemirror::Schema.tiptap
|
|
157
|
+
|
|
158
|
+
# prosemirror-schema-basic and prosemirror-schema-list
|
|
159
|
+
Prosemirror::Schema.prosemirror
|
|
135
160
|
```
|
|
136
161
|
|
|
137
162
|
Type names are written in snake_case (`bullet_list`). Use `case: :camel` to write camelCase (`bulletList`), as Tiptap does. Both cases are always accepted on input.
|
|
@@ -140,6 +165,26 @@ Type names are written in snake_case (`bullet_list`). Use `case: :camel` to writ
|
|
|
140
165
|
Prosemirror.schema = Prosemirror::Schema.tiptap(case: :camel)
|
|
141
166
|
```
|
|
142
167
|
|
|
168
|
+
`text_align: true` adds Tiptap's TextAlign extension, configured for headings and paragraphs: a `textAlign` attribute, `left`, `center`, `right` or `justify`, rendered as a `text-align` style. HTML import also reads the old `align` attribute. Markdown has no syntax for it: Markdown export leaves it out. Without the option, `textAlign` is kept in `to_h`, but not rendered.
|
|
169
|
+
|
|
170
|
+
```ruby
|
|
171
|
+
Prosemirror::Schema.tiptap(text_align: true)
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
YouTube and Twitch embeds store the video's URL in `src`, and render an iframe of its embed URL, built from the video's id: other URLs render nothing. HTML import reads Tiptap's markup, and also iframes of embed URLs without it. Twitch only plays embeds on the domain they name, so set it in a subclass, as Tiptap's `parent` option. YouTube's `nocookie` option is a subclass too:
|
|
175
|
+
|
|
176
|
+
```ruby
|
|
177
|
+
class Twitch < Prosemirror::Nodes::Twitch
|
|
178
|
+
def parent = "example.com"
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
class Youtube < Prosemirror::Nodes::Youtube
|
|
182
|
+
def embed_host = "https://www.youtube-nocookie.com"
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
Prosemirror.schema = Prosemirror::Schema.tiptap.with(Twitch, Youtube)
|
|
186
|
+
```
|
|
187
|
+
|
|
143
188
|
## Custom nodes and marks
|
|
144
189
|
|
|
145
190
|
A node type is a Phlex component, and its attributes are Literal properties. The type name comes from the class name (`ImageBlock` → `image_block`).
|
data/ROADMAP.md
CHANGED
|
@@ -192,14 +192,18 @@ They differ in more than names:
|
|
|
192
192
|
```
|
|
193
193
|
- Validation against the schema (`check`).
|
|
194
194
|
|
|
195
|
-
### 0.5: Markdown export
|
|
195
|
+
### 0.5: Markdown export (released)
|
|
196
196
|
|
|
197
|
-
- `doc.to_markdown`,
|
|
198
|
-
-
|
|
197
|
+
- `doc.to_markdown`, ported from [`prosemirror-markdown`](https://github.com/ProseMirror/prosemirror-markdown)'s serializer. Useful to send content to LLMs.
|
|
198
|
+
- GitHub Flavored Markdown tables and strikethrough. Formatting without Markdown syntax, like underline, keeps its text, or is written as HTML with `html: true`.
|
|
199
|
+
- Custom node types write themselves in `to_markdown`, and mark types declare their syntax with `markdown`.
|
|
200
|
+
- Importing the Markdown gives the document back, as far as Markdown can hold it, tested on real-world Markdown and HTML.
|
|
199
201
|
|
|
200
|
-
### 0.6: More Tiptap node types
|
|
202
|
+
### 0.6: More Tiptap node types (released)
|
|
201
203
|
|
|
202
|
-
-
|
|
204
|
+
- Mentions, task lists, details, and YouTube and Twitch embeds, in `Schema.tiptap`, with HTML that gives the same JSON as Tiptap's `generateJSON`.
|
|
205
|
+
- Text alignment, as an option: `Schema.tiptap(text_align: true)`.
|
|
206
|
+
- GitHub Flavored Markdown task lists in Markdown import and export. Details are written as GitHub's `<details>` HTML.
|
|
203
207
|
|
|
204
208
|
### 1.0
|
|
205
209
|
|
data/TYPES.md
CHANGED
|
@@ -27,6 +27,7 @@ A type is a [Phlex](https://www.phlex.fun) component, and its attributes are [Li
|
|
|
27
27
|
- [Rendering](#rendering)
|
|
28
28
|
- [Parse rules](#parse-rules)
|
|
29
29
|
- [Markdown](#markdown)
|
|
30
|
+
- [Markdown export](#markdown-export)
|
|
30
31
|
- [Subclassing built-in types](#subclassing-built-in-types)
|
|
31
32
|
|
|
32
33
|
## Names
|
|
@@ -203,6 +204,48 @@ Markdown import builds the elements commonmarker renders for each Markdown node,
|
|
|
203
204
|
|
|
204
205
|
Raw HTML in Markdown goes through HTML import when the herb gem is installed, and stays as text otherwise.
|
|
205
206
|
|
|
207
|
+
## Markdown export
|
|
208
|
+
|
|
209
|
+
A node type writes itself in `to_markdown`, with the methods of `Prosemirror::MarkdownSerializer`, ported from prosemirror-markdown's `MarkdownSerializerState`. As in `view_template`, attributes are instance variables, and `node` is the node:
|
|
210
|
+
|
|
211
|
+
```ruby
|
|
212
|
+
class Callout < Prosemirror::NodeType
|
|
213
|
+
def to_markdown(markdown, parent, index)
|
|
214
|
+
markdown.wrap_block("> ", "> **Note:** ", node) { markdown.render_content(node) }
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
| Method | Does |
|
|
220
|
+
|---|---|
|
|
221
|
+
| `write(text)` | Writes text as it is, after closing the previous block. |
|
|
222
|
+
| `text(text, escape: true)` | Writes text, escaped. |
|
|
223
|
+
| `render_inline(node)` | Writes the node's inline content, with its marks. |
|
|
224
|
+
| `render_content(node)` | Writes the node's blocks. |
|
|
225
|
+
| `wrap_block(delim, first_delim, node) { … }` | Writes a block with each line prefixed, like `> `. |
|
|
226
|
+
| `render_list(node, delim) { \|index\| marker }` | Writes a list. |
|
|
227
|
+
| `close_block(node)` | Ends the block: the next one starts after a blank line. |
|
|
228
|
+
| `esc(text)`, `url(href)`, `node_html(node)`, `html?` | Escaping, a link destination, a node's HTML, and whether `html: true` was given. |
|
|
229
|
+
|
|
230
|
+
Without `to_markdown`, a type writes its content.
|
|
231
|
+
|
|
232
|
+
A mark type declares its syntax with `markdown`, as prosemirror-markdown's mark specs:
|
|
233
|
+
|
|
234
|
+
```ruby
|
|
235
|
+
class Keyboard < Prosemirror::MarkType
|
|
236
|
+
markdown open: "<kbd>", close: "</kbd>", escape: false
|
|
237
|
+
end
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
| Option | Meaning |
|
|
241
|
+
|---|---|
|
|
242
|
+
| `open`, `close` | Strings, or blocks called with the serializer, the mark, the parent node and the index of the marked node. |
|
|
243
|
+
| `mixable: true` | The mark can open and close in any order with other mixable marks, like `*` and `**`. |
|
|
244
|
+
| `expel_enclosing_whitespace: true` | Spaces at the edges move outside the mark, as emphasis requires. |
|
|
245
|
+
| `escape: false` | The content isn't escaped, as in inline code. The mark must come last. |
|
|
246
|
+
|
|
247
|
+
Without `markdown`, a mark writes its text, or its HTML with `to_markdown(html: true)`.
|
|
248
|
+
|
|
206
249
|
## Subclassing built-in types
|
|
207
250
|
|
|
208
251
|
A subclass keeps the name, options, attributes and parse rules of its parent, and can change any of them. Register it with `Schema#with`, which replaces the built-in type with the same name:
|
|
@@ -216,3 +259,15 @@ Prosemirror.schema = Prosemirror::Schema.tiptap.with(Paragraph)
|
|
|
216
259
|
```
|
|
217
260
|
|
|
218
261
|
A subclass that declares parse rules replaces the rules of its parent. See the [cookbook](COOKBOOK.md) for more examples.
|
|
262
|
+
|
|
263
|
+
With `Schema.tiptap(text_align: true)`, paragraphs and headings are `Prosemirror::TextAlign::Paragraph` and `Prosemirror::TextAlign::Heading`: subclass these to keep the alignment. Other types get it by including `Prosemirror::TextAlign`, which declares the `text_align` attribute, and rendering `text_align_style`:
|
|
264
|
+
|
|
265
|
+
```ruby
|
|
266
|
+
class Callout < Prosemirror::NodeType
|
|
267
|
+
include Prosemirror::TextAlign
|
|
268
|
+
group :block
|
|
269
|
+
content "inline*"
|
|
270
|
+
|
|
271
|
+
def view_template(&) = aside(style: text_align_style, &)
|
|
272
|
+
end
|
|
273
|
+
```
|
data/lib/prosemirror/checker.rb
CHANGED
|
@@ -63,10 +63,10 @@ module Prosemirror
|
|
|
63
63
|
invalid("invalid marks: #{node.marks.map { |mark| @schema.json_type_name(mark.type) }.join(" ")}", path) unless sorted == node.marks
|
|
64
64
|
end
|
|
65
65
|
|
|
66
|
-
# Attributes are valid when building them from
|
|
66
|
+
# Attributes are valid when building them from their JSON reports nothing and changes nothing.
|
|
67
67
|
def check_attrs(type, attrs, path)
|
|
68
68
|
problems = []
|
|
69
|
-
built = type.build_attrs(attrs, report: ->(message) { problems << message })
|
|
69
|
+
built = type.build_attrs(type.serialize_attrs(attrs), report: ->(message) { problems << message })
|
|
70
70
|
invalid(problems.first, path) unless problems.empty?
|
|
71
71
|
invalid("invalid attributes: #{attrs.inspect}", path) unless built == attrs
|
|
72
72
|
end
|
data/lib/prosemirror/coerce.rb
CHANGED
|
@@ -13,5 +13,12 @@ module Prosemirror
|
|
|
13
13
|
|
|
14
14
|
# A frozen copy, unless the value is already frozen.
|
|
15
15
|
FROZEN = proc { |value| value.frozen? ? value : value.dup.freeze }
|
|
16
|
+
|
|
17
|
+
# The coercions above, which don't use `self`.
|
|
18
|
+
PLAIN = [INTEGER, SYMBOL, FROZEN].freeze
|
|
19
|
+
|
|
20
|
+
def self.frozen(value)
|
|
21
|
+
value.frozen? ? value : value.dup.freeze
|
|
22
|
+
end
|
|
16
23
|
end
|
|
17
24
|
end
|
data/lib/prosemirror/document.rb
CHANGED
|
@@ -47,6 +47,12 @@ module Prosemirror
|
|
|
47
47
|
TextSerializer.new(schema, block_separator: block_separator).call(root)
|
|
48
48
|
end
|
|
49
49
|
|
|
50
|
+
# The document as Markdown (GitHub Flavored Markdown for tables and strikethrough). Formatting Markdown
|
|
51
|
+
# has no syntax for, like underline, keeps only its text, or is written as HTML with `html: true`.
|
|
52
|
+
def to_markdown(html: false)
|
|
53
|
+
MarkdownSerializer.new(schema, html: html).call(root)
|
|
54
|
+
end
|
|
55
|
+
|
|
50
56
|
# The document as JSON, as a Hash.
|
|
51
57
|
def to_h
|
|
52
58
|
schema.serialize(root)
|
|
@@ -15,22 +15,30 @@ module Prosemirror
|
|
|
15
15
|
|
|
16
16
|
def initialize(source)
|
|
17
17
|
@source = source
|
|
18
|
-
@
|
|
18
|
+
@names = []
|
|
19
|
+
@compounds = source.split(",").map { |part| parse_compound(part.strip, top: true) }
|
|
20
|
+
@names = @names.include?(nil) ? nil : @names.uniq.freeze
|
|
19
21
|
end
|
|
20
22
|
|
|
21
23
|
def match?(element)
|
|
22
24
|
@compounds.any? { |tests| tests.all? { |test| test.call(element) } }
|
|
23
25
|
end
|
|
24
26
|
|
|
27
|
+
# The element names it can match, or nil when it can match any element.
|
|
28
|
+
attr_reader :names
|
|
29
|
+
|
|
25
30
|
private
|
|
26
31
|
|
|
27
|
-
def parse_compound(part)
|
|
32
|
+
def parse_compound(part, top: false)
|
|
28
33
|
invalid! if part.empty?
|
|
29
34
|
|
|
30
35
|
tests = []
|
|
31
36
|
rest = part
|
|
32
|
-
|
|
33
|
-
|
|
37
|
+
type = rest[/\A(?:\*|#{IDENTIFIER})/o]
|
|
38
|
+
name = type.downcase.freeze if type && type != "*"
|
|
39
|
+
@names << name if top
|
|
40
|
+
if type
|
|
41
|
+
tests << ->(element) { element.name == name } if name
|
|
34
42
|
rest = rest[type.length..]
|
|
35
43
|
end
|
|
36
44
|
until rest.empty?
|
|
@@ -34,6 +34,11 @@ module Prosemirror
|
|
|
34
34
|
rules = (schema.marks + schema.nodes).flat_map(&:parse_rules)
|
|
35
35
|
rules = rules.each_with_index.sort_by { |rule, index| [-rule.priority, index] }.map(&:first)
|
|
36
36
|
@tags = rules.select(&:tag)
|
|
37
|
+
@selectors = @tags.map { |rule| HTML::Selector.parse(rule.tag) }
|
|
38
|
+
# The indexes of the tag rules that can match an element with this name, in order.
|
|
39
|
+
@candidates = Hash.new do |candidates, name|
|
|
40
|
+
candidates[name] = @selectors.each_index.select { |index| @selectors[index].names.nil? || @selectors[index].names.include?(name) }.freeze
|
|
41
|
+
end
|
|
37
42
|
@styles = rules.select(&:style)
|
|
38
43
|
@matched_styles = @styles.map(&:style_property).uniq
|
|
39
44
|
@normalize_lists = @tags.none? { |rule| lists_contain_themselves?(rule) }
|
|
@@ -53,9 +58,11 @@ module Prosemirror
|
|
|
53
58
|
|
|
54
59
|
# The first tag rule after `after` that matches the element in this context.
|
|
55
60
|
def match_tag(element, context, after = nil)
|
|
56
|
-
|
|
61
|
+
@candidates[element.name].each do |index|
|
|
62
|
+
next if after && index <= after
|
|
63
|
+
|
|
57
64
|
rule = @tags[index]
|
|
58
|
-
next unless
|
|
65
|
+
next unless @selectors[index].match?(element)
|
|
59
66
|
next if rule.context && !context.matches_context?(rule.context)
|
|
60
67
|
|
|
61
68
|
match = build_match(rule, index, element) { |get_attrs| call_get_attrs(get_attrs, element) }
|
|
@@ -6,19 +6,26 @@ module Prosemirror
|
|
|
6
6
|
# Invalid parts are fixed and reported to the schema (which raises in strict mode).
|
|
7
7
|
class JSONParser
|
|
8
8
|
MAX_DEPTH = 100
|
|
9
|
+
EMPTY = [].freeze
|
|
9
10
|
|
|
10
11
|
def initialize(schema)
|
|
11
12
|
@schema = schema
|
|
13
|
+
# The path of the node being built, as `["doc", :content, 0, :marks, 1]`: a string is only made for
|
|
14
|
+
# a report.
|
|
15
|
+
@path = []
|
|
16
|
+
@report = ->(message) { invalid(message) }
|
|
12
17
|
end
|
|
13
18
|
|
|
14
19
|
def parse(json)
|
|
15
|
-
|
|
20
|
+
# Frozen strings go into nodes without being copied.
|
|
21
|
+
hash = json.is_a?(String) ? JSON.parse(json, max_nesting: (MAX_DEPTH * 2) + 2, freeze: true) : json
|
|
16
22
|
raise ArgumentError, "expected a Hash or a JSON String, got #{json.class}" unless hash.is_a?(Hash)
|
|
17
23
|
|
|
18
|
-
|
|
24
|
+
@path = ["doc"]
|
|
25
|
+
root = build_node(hash, 0) || Node.new(type: @schema.top_node)
|
|
19
26
|
return root if root.type == @schema.top_node
|
|
20
27
|
|
|
21
|
-
|
|
28
|
+
invalid("the top node is #{root.type}, not #{@schema.top_node}")
|
|
22
29
|
Node.new(type: @schema.top_node, content: [root])
|
|
23
30
|
rescue JSON::NestingError
|
|
24
31
|
raise InvalidDocument, "the document is nested more than #{MAX_DEPTH} levels deep"
|
|
@@ -26,88 +33,112 @@ module Prosemirror
|
|
|
26
33
|
|
|
27
34
|
private
|
|
28
35
|
|
|
29
|
-
def build_node(hash,
|
|
30
|
-
return invalid("not a node: #{hash.inspect}"
|
|
31
|
-
return invalid("nested more than #{MAX_DEPTH} levels deep"
|
|
36
|
+
def build_node(hash, depth)
|
|
37
|
+
return invalid("not a node: #{hash.inspect}") unless hash.is_a?(Hash)
|
|
38
|
+
return invalid("nested more than #{MAX_DEPTH} levels deep") if depth > MAX_DEPTH
|
|
32
39
|
|
|
33
40
|
name = fetch(hash, "type")
|
|
34
|
-
return invalid("missing node type"
|
|
35
|
-
return build_text(hash
|
|
41
|
+
return invalid("missing node type") unless name.is_a?(String) || name.is_a?(Symbol)
|
|
42
|
+
return build_text(hash) if name == "text" || name == :text
|
|
36
43
|
|
|
37
44
|
node_type = @schema.node_type(name)
|
|
38
|
-
return build_unknown(hash, name,
|
|
45
|
+
return build_unknown(hash, name, depth) unless node_type
|
|
39
46
|
|
|
40
47
|
given = fetch(hash, "attrs")
|
|
41
|
-
attrs = node_type.build_attrs(given, report:
|
|
42
|
-
return invalid("removed #{name} node"
|
|
48
|
+
attrs = node_type.build_attrs(given, report: @report)
|
|
49
|
+
return invalid("removed #{name} node") unless attrs
|
|
43
50
|
|
|
44
|
-
Node.new(type: node_type.type_name, attrs: attrs, unknown_attrs: node_type.unknown_attrs(given),
|
|
45
|
-
content: build_content(hash,
|
|
51
|
+
Node.new(type: node_type.type_name, attrs: attrs.freeze, unknown_attrs: node_type.unknown_attrs(given).freeze,
|
|
52
|
+
content: build_content(hash, depth), marks: build_marks(fetch(hash, "marks")))
|
|
46
53
|
end
|
|
47
54
|
|
|
48
|
-
def build_text(hash
|
|
55
|
+
def build_text(hash)
|
|
49
56
|
text = fetch(hash, "text")
|
|
50
|
-
return invalid("text node without text"
|
|
57
|
+
return invalid("text node without text") unless text.is_a?(String)
|
|
51
58
|
return nil if text.empty?
|
|
52
59
|
|
|
53
|
-
Node.new(type: :text, text: text, marks: build_marks(fetch(hash, "marks")
|
|
60
|
+
Node.new(type: :text, text: text, marks: build_marks(fetch(hash, "marks")))
|
|
54
61
|
end
|
|
55
62
|
|
|
56
|
-
def build_unknown(hash, name,
|
|
57
|
-
invalid("unknown node type #{name}"
|
|
63
|
+
def build_unknown(hash, name, depth)
|
|
64
|
+
invalid("unknown node type #{name}")
|
|
58
65
|
attrs = fetch(hash, "attrs")
|
|
59
66
|
Node.new(type: Naming.snake(name), attrs: attrs.is_a?(Hash) ? attrs.transform_keys(&:to_sym) : {},
|
|
60
|
-
content: build_content(hash,
|
|
67
|
+
content: build_content(hash, depth), marks: build_marks(fetch(hash, "marks")))
|
|
61
68
|
end
|
|
62
69
|
|
|
63
|
-
def build_content(hash,
|
|
70
|
+
def build_content(hash, depth)
|
|
64
71
|
content = fetch(hash, "content")
|
|
65
|
-
return
|
|
66
|
-
return invalid("content is not a list"
|
|
72
|
+
return EMPTY if content.nil?
|
|
73
|
+
return invalid("content is not a list") || EMPTY unless content.is_a?(Array)
|
|
74
|
+
|
|
75
|
+
nodes = []
|
|
76
|
+
content.each_with_index do |child, index|
|
|
77
|
+
@path.push(:content, index)
|
|
78
|
+
node = build_node(child, depth + 1)
|
|
79
|
+
@path.pop
|
|
80
|
+
@path.pop
|
|
81
|
+
nodes << node if node
|
|
82
|
+
end
|
|
83
|
+
Node.join_text(nodes).freeze
|
|
84
|
+
end
|
|
67
85
|
|
|
68
|
-
|
|
69
|
-
|
|
86
|
+
def build_marks(marks)
|
|
87
|
+
return EMPTY if marks.nil?
|
|
88
|
+
return invalid("marks is not a list") || EMPTY unless marks.is_a?(Array)
|
|
89
|
+
|
|
90
|
+
built = []
|
|
91
|
+
marks.each_with_index do |mark, index|
|
|
92
|
+
@path.push(:marks, index)
|
|
93
|
+
mark = build_mark(mark)
|
|
94
|
+
@path.pop
|
|
95
|
+
@path.pop
|
|
96
|
+
built << mark if mark
|
|
70
97
|
end
|
|
71
|
-
|
|
98
|
+
sort_marks(built).freeze
|
|
72
99
|
end
|
|
73
100
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
return
|
|
101
|
+
# The marks without duplicates, in schema order. Most sets have one mark, or are already sorted.
|
|
102
|
+
def sort_marks(marks)
|
|
103
|
+
return marks if marks.size < 2
|
|
104
|
+
|
|
105
|
+
marks = marks.uniq
|
|
106
|
+
ranks = marks.map { |mark| @schema.mark_rank(mark.type) }
|
|
107
|
+
return marks if ranks.each_cons(2).all? { |a, b| a <= b }
|
|
77
108
|
|
|
78
|
-
|
|
79
|
-
built.uniq.sort_by.with_index { |mark, index| [@schema.mark_rank(mark.type), index] }
|
|
109
|
+
marks.each_with_index.sort_by { |mark, index| [ranks[index], index] }.map(&:first)
|
|
80
110
|
end
|
|
81
111
|
|
|
82
|
-
def build_mark(hash
|
|
83
|
-
return invalid("not a mark: #{hash.inspect}"
|
|
112
|
+
def build_mark(hash)
|
|
113
|
+
return invalid("not a mark: #{hash.inspect}") unless hash.is_a?(Hash)
|
|
84
114
|
|
|
85
115
|
name = fetch(hash, "type")
|
|
86
|
-
return invalid("missing mark type"
|
|
116
|
+
return invalid("missing mark type") unless name.is_a?(String) || name.is_a?(Symbol)
|
|
87
117
|
|
|
88
118
|
mark_type = @schema.mark_type(name)
|
|
89
119
|
unless mark_type
|
|
90
|
-
invalid("unknown mark type #{name}"
|
|
120
|
+
invalid("unknown mark type #{name}")
|
|
91
121
|
attrs = fetch(hash, "attrs")
|
|
92
122
|
return Mark.new(type: Naming.snake(name), attrs: attrs.is_a?(Hash) ? attrs.transform_keys(&:to_sym) : {})
|
|
93
123
|
end
|
|
94
124
|
|
|
95
125
|
given = fetch(hash, "attrs")
|
|
96
|
-
attrs = mark_type.build_attrs(given, report:
|
|
97
|
-
return invalid("removed #{name} mark"
|
|
126
|
+
attrs = mark_type.build_attrs(given, report: @report)
|
|
127
|
+
return invalid("removed #{name} mark") unless attrs
|
|
98
128
|
|
|
99
|
-
Mark.new(type: mark_type.type_name, attrs: attrs, unknown_attrs: mark_type.unknown_attrs(given))
|
|
129
|
+
Mark.new(type: mark_type.type_name, attrs: attrs.freeze, unknown_attrs: mark_type.unknown_attrs(given).freeze)
|
|
100
130
|
end
|
|
101
131
|
|
|
102
132
|
def fetch(hash, key)
|
|
103
133
|
hash.key?(key) ? hash[key] : hash[key.to_sym]
|
|
104
134
|
end
|
|
105
135
|
|
|
106
|
-
|
|
107
|
-
|
|
136
|
+
# The path as written in reports: `doc.content[0].marks[1]`.
|
|
137
|
+
def path
|
|
138
|
+
([@path.first] + @path.drop(1).each_slice(2).map { |name, index| "#{name}[#{index}]" }).join(".")
|
|
108
139
|
end
|
|
109
140
|
|
|
110
|
-
def invalid(message
|
|
141
|
+
def invalid(message)
|
|
111
142
|
@schema.report(message, path: path)
|
|
112
143
|
end
|
|
113
144
|
end
|
data/lib/prosemirror/mark.rb
CHANGED
|
@@ -7,9 +7,14 @@ module Prosemirror
|
|
|
7
7
|
#
|
|
8
8
|
# As for nodes, `unknown_attrs` holds the attributes the mark's type doesn't declare.
|
|
9
9
|
class Mark < Literal::Data
|
|
10
|
-
prop :type, Symbol
|
|
11
|
-
prop :attrs, _Hash(Symbol, _Any?), default:
|
|
12
|
-
prop :unknown_attrs, _Hash(String, _Any?), default:
|
|
10
|
+
prop :type, Symbol
|
|
11
|
+
prop :attrs, _Hash(Symbol, _Any?), default: Node::EMPTY_HASH
|
|
12
|
+
prop :unknown_attrs, _Hash(String, _Any?), default: Node::EMPTY_HASH
|
|
13
|
+
|
|
14
|
+
# As Node#initialize.
|
|
15
|
+
def initialize(type:, attrs: Node::EMPTY_HASH, unknown_attrs: Node::EMPTY_HASH)
|
|
16
|
+
super(type: type.is_a?(String) ? type.to_sym : type, attrs: Coerce.frozen(attrs), unknown_attrs: Coerce.frozen(unknown_attrs))
|
|
17
|
+
end
|
|
13
18
|
|
|
14
19
|
# A copy with some properties changed.
|
|
15
20
|
def with(**changes)
|
|
@@ -44,6 +44,21 @@ module Prosemirror
|
|
|
44
44
|
inherited_setting(:@group, [])
|
|
45
45
|
end
|
|
46
46
|
|
|
47
|
+
# The mark's Markdown syntax, as prosemirror-markdown's MarkSerializerSpec:
|
|
48
|
+
# - `open` and `close`: strings, or blocks called with the MarkdownSerializer, the mark, the parent node
|
|
49
|
+
# and the index of the marked node;
|
|
50
|
+
# - `mixable`: the mark can open and close in any order with other mixable marks, like `*` and `**`;
|
|
51
|
+
# - `expel_enclosing_whitespace`: spaces at the edges move outside the mark, as emphasis requires;
|
|
52
|
+
# - `escape: false`: the content isn't escaped, as in inline code. The mark must come last.
|
|
53
|
+
# Without it, the mark keeps only its text, or its HTML with `to_markdown(html: true)`.
|
|
54
|
+
def markdown(open:, close:, mixable: false, expel_enclosing_whitespace: false, escape: true)
|
|
55
|
+
@markdown_spec = {open:, close:, mixable:, expel_enclosing_whitespace:, escape:}.freeze
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def markdown_spec
|
|
59
|
+
inherited_setting(:@markdown_spec, nil)
|
|
60
|
+
end
|
|
61
|
+
|
|
47
62
|
# Whether one element can wrap several adjacent nodes with this mark. Defaults to true.
|
|
48
63
|
def spanning(value = nil)
|
|
49
64
|
return @spanning = value unless value.nil?
|